관계형 데이터베이스의 기본 키를 고르는 기준
관계형 데이터베이스의 기본 키를 고르는 기준
짧고 안정적이며 변경되지 않는 키를 선택한다. 업무 의미가 있는 값은 unique constraint로 보장하되 별도 인조 키를 기본 키로 둘 수 있다.
목차
- #문제가 되는 상황
- #기본 키가 제공하는 계약
- #좋은 기본 키의 네 가지 성질
- #키 폭은 참조 테이블 전체에 전파된다
- #복합 기본 키가 자연스러운 경우
- #내부 키와 외부 식별자 분리
- #기본 키가 권한 검사를 대신하지 않는다
- #이미 운영 중인 키를 바꾸는 비용
- #선택 절차와 비교표
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
사용자 테이블의 기본 키로 이메일을 선택하면 처음에는 자연스럽다. 이메일은 이미 유일하고 화면에서도 사용자를 찾을 때 쓰기 때문이다. 하지만 사용자가 이메일을 바꾸거나 대소문자·국제화 정규화 규칙이 달라지면 모든 참조 관계에 변경이 전파된다. 외래 키와 로그에도 개인정보가 반복된다.
기본 키는 단지 WHERE 조건에 쓰는 컬럼이 아니다. 행의 정체성을 나타내고 다른 테이블의 외래 키, ORM identity map, cache key, 이벤트 payload와 API 경계까지 영향을 줄 수 있다. 처음 생성할 때보다 바꾸는 비용이 훨씬 크므로 업무 의미와 저장 구조를 함께 보고 선택해야 한다.
사용자와 주문 schema는 키 선택의 영향을 설명하기 위한 가상 예제다. 실제 사용자 정보나 프로젝트 테이블을 사용하지 않았다.
기본 키가 제공하는 계약
관계형 모델에서 기본 키는 각 행을 유일하게 식별한다. 일반적으로 다음을 기대한다.
- 같은 테이블 안에서 중복되지 않는다.
NULL이 될 수 없다.- 외래 키가 참조할 안정적인 대상이 된다.
- 행의 다른 속성과 구분되는 정체성을 표현한다.
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_users_email (email)
);
id는 행의 identity이고 email의 업무상 중복 금지는 별도 unique constraint가 담당한다. 인조 키를 추가했다고 업무 중복 제약을 제거해도 된다는 뜻은 아니다.
좋은 기본 키의 네 가지 성질
유일성
현재 데이터에서 우연히 중복이 없다는 것과 앞으로도 도메인 규칙상 유일하다는 것은 다르다. 이름·전화번호처럼 변경과 공유 가능성이 있는 값은 안정적인 식별자가 아니다.
안정성
기본 키 값은 가능하면 행 생명주기 동안 바뀌지 않아야 한다. 키 변경은 모든 참조와 cache를 함께 갱신해야 한다. ON UPDATE CASCADE가 있어도 외부 이벤트와 데이터 warehouse까지 자동으로 바뀌는 것은 아니다.
최소성
행을 식별하는 데 불필요한 컬럼을 포함하지 않는다. (country_code, email, created_at) 중 (tenant_id, email)만으로 유일하다면 나머지 컬럼은 키에 들어갈 이유가 없다.
작은 크기
짧은 키는 index와 외래 키 저장 비용을 줄인다. 문자열 255자보다 8바이트 정수가 비교와 저장에 유리한 경우가 많다. 하지만 크기만을 위해 분산 생성 요구나 외부 노출 정책을 무시해서는 안 된다.
키 폭은 참조 테이블 전체에 전파된다
사용자 ID는 주문, 댓글, 세션, 감사 로그 등 여러 테이블에 반복된다.
flowchart LR
U[users.id] --> O[orders.user_id]
U --> C[comments.user_id]
U --> S[sessions.user_id]
U --> A[audit_logs.actor_user_id]복합 문자열 기본 키가 100바이트이고 5개 테이블에 수천만 행이 있다면 키 크기 차이가 외래 키 index에도 누적된다.
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
total_minor BIGINT NOT NULL,
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
);
저장 엔진에 따라 기본 키가 보조 index 구조에 포함되거나 row 위치를 찾는 데 사용될 수 있다. 따라서 “users 테이블 한 곳의 컬럼 크기”만 보고 판단하지 말고 보조 index와 참조 테이블까지 계산한다.
복합 기본 키가 자연스러운 경우
모든 테이블에 단일 id가 필요한 것은 아니다. 두 entity 사이의 관계 자체를 저장하는 join table은 두 외래 키 조합이 행을 정확히 식별할 수 있다.
CREATE TABLE project_members (
project_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
role VARCHAR(30) NOT NULL,
joined_at DATETIME NOT NULL,
PRIMARY KEY (project_id, user_id),
FOREIGN KEY (project_id) REFERENCES projects(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
이 경우 별도 surrogate ID를 추가해도 (project_id, user_id) unique constraint는 여전히 필요하다. 관계를 다른 테이블이 자주 참조하거나 관계 자체가 독립적인 생명주기·public ID를 가지면 단일 ID를 추가할 이유가 생길 수 있다.
복합 키를 선택하면 이를 참조하는 외래 키도 여러 컬럼이 되고 ORM과 API parameter가 복잡해질 수 있다. “DB에서 자연스럽다”와 “시스템 전체에서 단순하다”를 함께 본다.
내부 키와 외부 식별자 분리
내부에는 작은 정수 기본 키를 사용하고 외부 API에는 별도 public ID를 노출할 수 있다.
CREATE TABLE documents (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
public_id BINARY(16) NOT NULL,
owner_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_documents_public_id (public_id)
);
GET /documents/0198f21a-7c42-7b2e-a9d1-example
장점은 내부 join과 cluster key를 작게 유지하면서 외부 URL 계약을 DB sequence와 분리하는 것이다. 비용은 index 하나와 두 식별자의 변환·운영 복잡성이다. 외부 노출이 없거나 추측 가능성이 문제가 되지 않는 내부 시스템에서는 굳이 둘로 나눌 필요가 없을 수도 있다.
public ID를 추가해도 권한 검사는 그대로 해야 한다. 찾기 어려운 식별자는 접근 제어가 아니다.
기본 키가 권한 검사를 대신하지 않는다
순차 숫자 ID는 다음 값이 추측 가능하다.
GET /invoices/10041
GET /invoices/10042
하지만 UUID로 바꿨다고 다른 사용자의 invoice를 반환해도 안전해지는 것은 아니다.
const invoice = await invoiceRepository.findByPublicId(request.params.id);
if (!invoice || invoice.ownerId !== request.user.id) {
throw new NotFoundError();
}
모든 조회와 변경에서 인증된 주체가 해당 리소스에 수행할 수 있는 작업인지 검사한다. public ID의 예측 불가능성은 enumeration 비용을 높이는 defense in depth일 뿐 인가를 대체하지 않는다.
이미 운영 중인 키를 바꾸는 비용
기본 키 migration은 단순한 ALTER TABLE 한 줄이 아니다. 큰 테이블에서는 lock과 index 재구성이 발생할 수 있고, 외래 키·cache·이벤트·분석 테이블까지 영향을 받는다.
정수 내부 키를 추가하는 migration 예시는 다음과 같은 단계가 될 수 있다.
- nullable 새 ID 컬럼과 sequence를 추가한다.
- 기존 행을 작은 batch로 backfill한다.
- 새 행에 양쪽 키를 쓰는 dual-write를 시작한다.
- 참조 테이블에 새 외래 키를 추가하고 backfill한다.
- 읽기를 새 키로 전환하고 일관성을 비교한다.
- 제약과
NOT NULL을 추가한다. - 충분한 관찰 뒤 구 참조를 제거한다.
SELECT COUNT(*)
FROM orders o
LEFT JOIN users u ON u.id = o.user_id
WHERE u.id IS NULL;
각 단계의 rollback과 데이터 검증 쿼리를 준비한다. 운영 중인 키가 완벽하지 않더라도 migration 위험이 이점보다 큰지 먼저 평가한다.
선택 절차와 비교표
| 후보 | 크기 | 생성 위치 | 변경 가능성 | 외부 노출 | 비고 |
|---|---|---|---|---|---|
| BIGINT sequence | 작음 | 중앙 DB | 낮음 | 추측 가능 | 단일 write DB에 단순 |
| UUID v4 | 16바이트 | 분산 가능 | 낮음 | 무작위 | index locality 검토 |
| 시간 정렬 UUID | 16바이트 | 분산 가능 | 낮음 | 시간 정보 일부 | 구현·byte order 통일 |
| 업무 문자열 | 가변 | 도메인 | 업무에 따라 높음 | 의미 노출 | unique constraint에 적합할 수 있음 |
| 복합 업무 키 | 여러 컬럼 | 도메인 | 구성 값에 따라 | 의미 노출 | join table에 자연스러움 |
선택 순서는 다음처럼 정리할 수 있다.
- 행을 식별하는 도메인 후보 키가 실제로 불변·유일한가?
- 다른 테이블이 얼마나 많이 참조하는가?
- ID를 여러 writer나 offline client가 생성해야 하는가?
- 외부에 노출되는 계약과 내부 저장 키를 나눌 이유가 있는가?
- 저장 엔진의 index 구조에서 키 크기와 삽입 패턴은 어떤가?
자연 키와 인조 키의 더 직접적인 비교는 자연 키와 인조 키의 장단점에서 이어서 다룬다.
실전 점검 목록
- 미래에도 유일하고 변경되지 않는 값인가?
- 모든 외래 키와 보조 index에 전파되는 크기를 계산했는가?
- 업무 중복을 막는 unique constraint가 별도로 있는가?
- 복합 키가 관계 identity를 정확히 표현하는가?
- 외부 ID 분리의 이점이 index·변환 비용보다 큰가?
- 식별자 추측 가능성과 권한 검사를 혼동하지 않는가?
- migration이 필요하다면 dual-write·backfill·rollback 계획이 있는가?
짧고 안정적이며 변경되지 않는 키를 선택한다. 업무 의미가 있는 값은 unique constraint로 보장하되 별도 인조 키를 기본 키로 둘 수 있다.
결론
기본 키는 행의 유일성뿐 아니라 외래 키, index 크기, cache와 외부 계약에 전파되는 장기 식별자다. 유일성·불변성·최소성·크기를 기준으로 후보를 평가하고, 업무 의미의 중복은 별도 unique constraint로 보장한다. 내부 키와 public ID를 분리할 수 있지만 권한 검사를 대신할 수 없으며, 운영 중인 키 변경은 단계적 backfill과 검증이 필요한 migration 프로젝트로 다뤄야 한다.